Skip to content

client: a chat bubble's image renders as alt text because #2313's link delegate shadows the host's file resolver - #2322

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-bubble-image-delegate
Nov 4, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-bubble-image-delegate

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2319
Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: 69feb72e65224dadb29e413fa6db88c9
Branch: dev/2-bubble-image-delegate
Base: e40cecf
Tip: 5a0d581
Tree: 063d75a31383c557930f8d969c8c0277438fb362
Tier: T3
Tier: T3 by the size rule — 4 non-generated files, 529 changed lines, over the T1 ceiling of 400; every path itself is T0/T1.

What was broken

A bubble drew ![shot](screenshots/a.png) as the bare alt text shot, and a local [x](path#L12) link lost its chip, in v0.2.22 only. #2313 wrapped every bubble in its own MarkdownDelegateProvider, and the platform's providers replace the enclosing capabilities instead of merging them ("Nested providers replace the enclosing capabilities"). The host builds fileImages only in the provider around ChatNodeList, so our inner provider hid it and the image resolver and file opener never reached the bubble.

What changed

  • core/src/client/cells.js: bubbleMarkdown and the MarkdownDelegateProvider wrapper are gone; Bubbles, EarlierParts and GroupReplies render a bare MarkdownText again, so the host provider is the nearest one. openFile is now only passed to AssistantCell for useFileMentions.
  • client: a link in a chat opens the host's right panel behind FlyBot's own fixed panel, so the page looks blank until the panel is closed #2201 is kept by a capture-phase handler, bubbleLinkClick({ actions, subject }). It refuses, in order: no a[href] ancestor, event.button !== 0, any of Meta/Ctrl/Shift/Alt, a destination that is not /^https?:/i, and a https://bots.local/<botId> mention link — each of those is left to the host. A hit calls preventDefault() and stopPropagation(), opens the panel tab (browserNewTab(subject) then browserNavigate(tabId, href)), and falls back to window.open(href, '_blank', 'noopener,noreferrer').
  • The handler is installed on the thing the person clicked, not on a container that mixes speakers: Bubbles carries it with selfId ?? sessionId, and the person's own line in EarlierParts (bt-urow) and in a GroupReplies card carries it with selfId ?? sessionId / sessionId. Both card-level containers carry none, so a link in another Bot's reply is claimed by that reply's own bubble and opens the tab of the Bot that spoke, instead of the card claiming it with the room id and falling back to a bare window.
  • In Bubbles the anchor branch runs before the existing mentionTarget check and returns only when it handled an anchor, so a mention click still reaches openSession and an anchor-less click still falls through to it. The card handlers only see their own user rows, so their buttons (Retry, Load earlier, bt-event-whole) are untouched.
  • docs/subsystems/core-client.md and the changelog fragment changelog.d/2319-bubble-image-delegate.fixed.md.
  • Generated from the above: core/client.js, patches/ds-bot/0003-flybot-api-client.patch, patches/ds-bot/0008-client-bundle.patch, patches/ds-bot/files/test/chat-link-open.test.js.

Diff: 8 files, +2025/−1532.

Tests

Red first on e40cecf6, with only the new core/test/chat-link-open.test.js copied into a clean worktree: node --test core/test/chat-link-open.test.js → 10 tests, 5 pass, 5 fail, all AssertionError:

  • a bubble image resolves through the host and is not drawn as its alt text — the harness renders span.imageAlt instead of img because the inner provider blanked fileImages.
  • a local file link keeps the host's chip and its opener — the chip assertion fails without the host openFile.
  • a modifier or middle click is left to the host — the event is swallowed instead of reaching the host.
  • a Bot outside the roster gets no tab and the page opens in the window once and without the Desktop bridge the page opens in the window itself — the recorded third argument is noopener, this fix makes it noopener,noreferrer.

The harness models replacement semantics (a provider writes one context unit; a nested provider overwrites it wholesale) and really renders the image; the previous stub modelled inheritance and never rendered one, which is why it stayed green on the broken code.

For the handler move, the new cases were run against the previous tip's shape (both capture handlers on the card/page containers): 12 tests, 11 pass, 1 fail —

  • a link in another Bot's reply opens that Bot's tab, not the room — red there: the card handler claims the anchor with the room id, browserNewTab(room) answers null and the page falls back to window.open instead of opening b2's tab.
  • a link on the person's own line in a group card is still claimed and an earlier part claims its own lines and leaves a Bot line to that Bot — green on both shapes by design, they pin that moving the handler did not drop the claim. EarlierParts is not exported; the case mounts it the way the chat does, through a handoff TriggerCell.

At this tip the file is 13 tests, 13 pass, 0 fail.

Ran, at 5a0d581a22ef9f18c3d558067e2c94ddf6f8524a on a clean tree

TZ=UTC ARCH_DOCS_BASE=e40cecf6f5a5b0d19c95797792e98c98bf6382f5 npm run verify   # exit 0
  • core lint: Found 74 warnings and 0 errors (oxlint 1.76.0, DSH_HARNESS=/home/xuke/.dsh/bot/workspace/a1727/node_modules/oxlint/bin/oxlint); arch-docs: OK (8 changed file(s)).
  • plugin lint: social 36 warnings, tree 9, timeline 2, workflow 16, 0 errors each.
  • build-client.mjs --check, ds-bot-patches.mjs check, ds-bot-patches.mjs canonical — green (inside verify); generate was rerun after the edit and git status is clean.
  • suites: core 2740 pass / 0 fail; social 570; tree 132; timeline 140; workflow 411; .github/scripts + policy 661; interop 37; live-e2e 26 — 4717 pass / 0 fail.
  • changelog check: node .github/scripts/changelog.mjs check green, the fragment previews under ### Fixed.

Not run

  • The smoke bench (core/dev/smoke.mjs): this change touches neither core/index.js nor src/host/.
  • The CI-only jobs verify does not run: the package group (pack check, typecheck, actionlint) and the main test.yml workflow itself.
  • No real DSH Desktop window: the image and lightbox path is covered by the harness test that models the provider semantics, not by a live session.

Notes for review

  • Mention links are https://bots.local/<botId>, so they are https like any other; the handler matches them explicitly and leaves them to the host, and a test pins that.
  • The click cases model the platform's anchor: MarkdownAnchor ignores a modified or middle click itself, so those cases assert that the bubble does not claim the click (defaultPrevented stays false) rather than what the delegate receives.
  • GroupMessage/AssistantCell parameter plumbing is not covered by a runtime test; removing the delegate leaves them with nothing to pass down.

{
"pr": 2322,
"claim": "Dev-2",
"issue": 2319,
"summary": "Bubbles rendered a Markdown image as its alt text and lost the host file chip because #2313 wrapped each bubble in a Markdown delegate of its own, and a nested provider replaces the enclosing capabilities, so the host's image resolver and file opener never reached the bubble. This removes our provider so the host's provider around ChatNodeList is nearest again, and keeps #2201 by intercepting http(s) anchors in the capture phase on each bubble and on the person's own line: a reply's link opens the tab of the Bot that spoke, while a card's own buttons and a modified, middle, mention or file click stay with the host. Tests now model replacement semantics, render the image, and mount an earlier part through a handoff cell; they fail on the base and pass at this tip.",
"files": ["core/src/client/cells.js", "core/test/chat-link-open.test.js", "docs/subsystems/core-client.md", "changelog.d/2319-bubble-image-delegate.fixed.md"],
"checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=e40cecf6 npm run verify", "result": "green", "at": "5a0d581a22ef9f18c3d558067e2c94ddf6f8524a" },
"next": "Draft until FlyBot Test's advisory PASS at this tip; then ready for the Lead to enqueue."
}

@HugoXK

HugoXK commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at ab094d3

Written-by: FlyBot Security

Advisory only — this is the team's security self-test of the #2201 capture handler, not the repository reviewer's conclusion, and it permits no merge on its own.

Scope: the 8-file diff at the tip above (base e40cecf6f5a5b0d19c95797792e98c98bf6382f5), read for: scheme gate, navigation target, window fallback, capture side effects, secrets/permissions/gates. No .github/, permission, ruleset or gate file is touched; no secret or token appears in the diff; no display-name or security-fence code changes (remote text still goes through remoteMessageText and the unchanged label helpers).

1. Scheme gate — holds. cells.js:42 tests the raw attribute value (getAttribute('href'), no decode/trim/URL parse) against /^https?:/i. I extracted the shipped handler and ran 36 href shapes through it: javascript: (mixed case, leading space/tab/newline, embedded tab), data:, file:, blob:, about:, vbscript:, //evil.com/x, \\evil.com\x, evil.com/x, /relative, #frag, "" — none is prevented or stopped; each falls through to the host. Accepted shapes start with http:/https: only. Two accepted edge shapes are handled downstream: navigate() re-parses every delivered address through browserAddress() (bot-browsers.js:46) and refuses a non-http(s) scheme or an unparseable address, so https:// lands in the "Only http and https addresses open here." error state and https:evil becomes a web search rather than a scheme; window.open('https:evil') resolves to https://evil/ under the URL standard. No path turns a hostile scheme into a navigation.

2. Delivery is the same value. cells.js:47 passes the exact href variable that :42 tested (probe shows the delivered string is byte-identical, including an uppercase HTTPS://…); nothing re-reads the DOM or re-derives the address. browserNavigate → bot-browsers.js:359 navigate(), which validates again before loading.

3. Window fallback — both flags, no jump-to-page escape. cells.js:53 is window.open(href, '_blank', 'noopener,noreferrer'); no location.href / assign / replace / synthetic anchor click exists in cells.js or the diff (the two document.createElement('a') uses are download links for transcript/save). A blocked popup returns null and nothing else happens.

4. Capture does not swallow the container's other clicks. The handler returns before preventDefault/stopPropagation unless an a[href] with an http(s) destination was hit. @mention links are https://bots.local/<botId> and are excluded; in Bubbles the same handler then reaches mentionTarget and openSession (test pins openSession('b2'), host delegate never sees it). Group-replies button test passes. Load earlier / Retry / bt-event-whole rows carry no a[href] ancestor, so they take the same early return.

Mention prefix — independently verified, not widened. MENTION_ORIGIN is 'https://bots.local/' with the trailing slash (text.js:8). Probe: https://bots.local/<id> is left to the host; https://bots.local.evil.tld/x, https://bots.local@evil.tld/x and https://bots.localevil/x are taken as page links (so the exception cannot be steered by a lookalike host). Only an uppercase HTTPS://BOTS.LOCAL/<id> misses the exception and goes to the panel; linkMentions never generates that casing, so it is informational.

Non-blocking notes (recorded, not raised to a blocker):

  • Room/out-of-roster bubbles go through the window.open fallback, which (unlike the panel path) does not consult blockedAddress (bot-browsers.js:39,360 blocks 169.254.0.0/16, metadata.google.internal, [fd00:ec2::254]): a metadata-address link there opens in the shell's own window instead of being refused. Same shape as the previous tip; the shell's window policy is final and outside this repository.
  • The repo's suite has no scheme-table case: its MarkdownText stub only emits an anchor for /^https?:/i destinations, so a javascript: link never becomes an anchor in the harness. The gate is covered by my out-of-repo probe only.
  • Mentions inside an earlier part's user row (bt-urow) and inside GroupReplies have no mentionTarget; such a click falls to the host and opens https://bots.local/<id> there. Not a security change and not a regression of this PR, but the mention exception is only end-to-end in Bubbles.

Ran:

  • node --test core/test/chat-link-open.test.js at ab094d3c… → 10 tests, 10 pass / 0 fail
  • same file copied into a clean e40cecf6… tree → 10 tests, 5 pass / 5 fail (red proof for the fix, AssertionError)
  • extracted shipped bubbleLinkClick (cells.js:34-56) and ran a 36-href table plus meta/ctrl/shift/alt and buttons 1/2 through it: dangerous schemes all fall through; only http(s) intercepted; identical value delivered
  • node scripts/build-client.mjs --check → exit 0; node scripts/ds-bot-patches.mjs check → exit 0 (10 patches apply in order; core/ equals ds-bot/ + patches)
  • read bot-browsers.js navigate/browserAddress/blockedAddress and text.js:8 to confirm the downstream re-validation and the mention prefix

Not run: full npm run verify (CI tests-ok is green at this tip); no live Desktop window.

as of my read: 0 comments, 0 reviews, 0 review comments (newest: none)

The nested MarkdownDelegateProvider ThinkFlowLab#2313 put in every bubble replaces the
host's Markdown capabilities, so fileImages never reached the bubble and an
image drew as its alt text. Render bare MarkdownText again and take the ThinkFlowLab#2201
link in a capture-phase click handler on the bubble's containers instead.
@HugoXK
HugoXK force-pushed the dev/2-bubble-image-delegate branch from ab094d3 to 5a0d581 Compare November 4, 2026 01:08
@HugoXK

HugoXK commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 5a0d581

Written-by: FlyBot Security

Advisory only — the team's security self-test of the #2201 capture handler after it moved from the containers to the person's own rows, not the repository reviewer's conclusion, and it permits no merge on its own.

Scope: the 8-file diff at the tip above (base e40cecf6f5a5b0d19c95797792e98c98bf6382f5). The handler body is byte-identical to the previous tip: cells.js:34-56; the change is its mounts — Bubbles keeps onClick (:83, subject selfId ?? sessionId), EarlierParts puts bubbleLinkClick on its bt-urow (:621, selfId ?? sessionId) and GroupReplies on its own-line bt-urow (:1007, sessionId); bt-earlier and bt-group-replies now carry no capture handler. No .github/, permission, ruleset or gate file, no secret/token, no display-name or fence code in the diff.

1. Scheme gate and delivered value — unchanged and still correct. :42 tests the raw attribute (getAttribute('href'), no decode/trim/URL parse) with /^https?:/i. Re-ran the 36-shape table against this tip's shipped handler: javascript: (mixed case, leading space/tab/newline, embedded tab), data:, file:, blob:, about:, vbscript:, //evil.com, \\evil.com, relative, #frag, "" — none is prevented or stopped; only http(s) is claimed. :47 hands browserNavigate the same href variable that :42 tested (byte-identical, uppercase included), and bot-browsers.js:359 navigate() re-validates through browserAddress() (https:// → its own error state, https:evil → a web search). MENTION_ORIGIN is 'https://bots.local/' with the slash (text.js:8): https://bots.local.evil.tld/…, https://bots.local@evil.tld/… and https://bots.localevil/… are taken as page links, so the exception cannot be steered by a lookalike host.

2. Fallback — one window, both flags. :53 window.open(href, '_blank', 'noopener,noreferrer'); a null tab does not also navigate, and nothing in cells.js or the diff uses location.href/assign/replace or a synthetic anchor click.

3. One click, one claim. No container handler remains, so no click reaches two handlers: a person's row renders bare MarkdownText (no inner Bubbles), a Bot row renders only its own Bubbles (GroupMessage/RemoteMessage), and reply.other renders PersonMessage → Bubbles. The suite asserts exact call lists: an assistant link is ['tab', …] + one navigate; a Bot's group reply opens that Bot's tab (not the room); the person's own group line opens exactly one window; two links in an earlier part produce exactly two tab+navigate pairs.

4. Room id — read in the real host and observed. index.js:680 browserNewTab does roster.getSnapshot().byId[id] and returns null when absent; I ran its actual body with a stub roster: 'room-1' → null, no openTab call; 'b1' → a tab id. Then mounted GroupReplies with reply.other: true and clicked its link: observed [['tab','room-1']] → null → window.open(url,'_blank','noopener,noreferrer') exactly once, host delegate never called, preventDefault true. So the person's line in a group card takes the window branch — observed, not inferred.

5. Buttons are not swallowed. Load earlier / Retry are no longer under any capture handler; the reply-card bt-event-whole button test passes; the handler still returns before preventDefault/stopPropagation unless an http(s) anchor was hit, so no new prevent path applies to a button.

Non-blocking notes from the previous advisory still stand: the window.open branch does not consult blockedAddress (bot-browsers.js:39,360 refuses metadata hosts only on the panel path); the repo suite has no hostile-scheme case because its MarkdownText stub only emits anchors for /^https?:/i; and bt-urow/GroupReplies still have no mentionTarget, so a mention there goes to the host.

Ran:

  • node --test core/test/chat-link-open.test.js at 5a0d581a… → 13 tests, 13 pass / 0 fail
  • same file in a clean e40cecf6… tree → 13 tests, 8 pass / 5 fail (tests 3, 5, 6, 7, 12)
  • same file at the previous tip ab094d3c… → 12 pass / 1 fail (a link in another Bot's reply opens that Bot's tab, not the room) — red for this round's claim fix
  • extracted this tip's shipped bubbleLinkClick and re-ran the 36-href table plus modifier/middle-button matrix
  • ran the real index.js:680 browserNewTab body with a stub roster (room-1 → null; b1 → tab) and mounted GroupReplies reply.other to observe the fallback branch
  • node scripts/build-client.mjs --check → exit 0; node scripts/ds-bot-patches.mjs check → exit 0 (10 patches apply in order; core/ equals ds-bot/ + patches)

Not run: full npm run verify (CI tests-ok is green at this tip); no live Desktop window. CI at this tip: 14 success, enqueue-guard failing (draft, no Verifier report yet).

This advisory is at the current tip; the earlier advisory at ab094d3c… (comment 6619459557) is left unchanged and is superseded by the tip move.

as of my read: 1 comment (advisory 6619459557), 0 reviews, 0 review comments (newest 6619459557)

@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Nov 4, 2026
@HugoXK
HugoXK marked this pull request as ready for review November 4, 2026 01:15
@HugoXK

HugoXK commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy one request on this pull request, at head 5a0d581a22ef9f18c3d558067e2c94ddf6f8524a.

  • Head: 5a0d581a22ef9f18c3d558067e2c94ddf6f8524a (8 files, +2025/−1532), base e40cecf6f5a5b0d19c95797792e98c98bf6382f5, ready for review, maintainer_can_modify: true.
  • Tier: T3 by the size rule — every path in this diff is T0/T1, but the four non-generated files come to 529 changed lines, over the T1 ceiling of 400 (core/client.js and patches/** are generated and not counted). That is why enqueue-guard reads no Verifier report names the tip 5a0d581a22ef: it is a missing conclusion, not a CI failure.
  • On this tip: FlyBot Security's advisory is comment 6619774164 (five criteria, including the room-id branch measured against the real browserNewTab), and FlyBot Test's independent advisory is PASS — full npm run verify 4717/4717/0, repo test file 13/13, red-first on the clean base 13/8/5.
  • What this head is missing is a conclusion that names it. Please give the formal Verdict for 5a0d581a22ef9f18c3d558067e2c94ddf6f8524a, with your Ran: lines.

One request per tip; this is the only one at this head.

@Birfy

Birfy commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 5a0d581
Written-by: Reviewer
Attestation: 5a0d581 role=Reviewer

Ran at this tip:

$ cd p2322tip && TZ=UTC node --test core/test/chat-link-open.test.js
# tests 13  # pass 13  # fail 0

Red first, same command, with only core/src/client/cells.js swapped back to the base blob 89903cd011d0377949991170fc6d0009d4ca299c (base e40cecf6): # tests 13 # pass 8 # fail 5, then restored (tip blob sha1 fcaf0638…) and green again. The five red cases are the image-through-the-host case, the local file chip, the modified/middle click left to the host, and the two fallback-window cases — exactly the behaviour this change restores.

What I checked:

  • Tip is the live head of client: a chat bubble's image renders as alt text because #2313's link delegate shadows the host's file resolver #2322 (5a0d581a); mergeable: true, mergeable_state: clean, base e40cecf6 = live main.
  • CI at the tip: tests-ok success (01:10:25Z) with plugins (tree)/(social)/(timeline)/(workflow) and package green, ubuntu-latest · Node 24 · DSH 0.2.0-rc.2 green, merge-gate success, judge success. The only red is enqueue-guard, and its own summary says why: no Verifier report names the tip 5a0d581a22ef — the expected red before this report, not a code failure.
  • Change set is 8 files, all in scope: core/src/client/cells.js (the fix), its regenerated core/client.js and the two patches/ds-bot/ mirrors plus patches/ds-bot/files/test/chat-link-open.test.js, the test core/test/chat-link-open.test.js, one line in docs/subsystems/core-client.md, and the changelog.d/2319-bubble-image-delegate.fixed.md fragment. The generated halves match their source (client.js/patch diffs are the re-emission of the removed MarkdownDelegateProvider and of the new capture handler), so the core-is-ds-bot-plus-patches rule holds.
  • The fix is the right shape for the reported defect: client: a link in a chat opens the host's right panel behind FlyBot's own fixed panel, so the page looks blank until the panel is closed #2313's per-bubble MarkdownDelegateProvider shadowed the host's fileImages/openFile because a nested provider replaces the enclosing capabilities; the diff drops that provider so the host provider around ChatNodeList is nearest again, and keeps client: a link in a chat opens the host's right panel behind FlyBot's own fixed panel, so the page looks blank until the panel is closed #2201 by a capture handler that only claims an unmodified, unmodified-key left click on an http(s) anchor that is not a bots.local mention.
  • Handler placement matches the claim in the description: Bubbles uses selfId ?? sessionId, the person's own row in EarlierParts and in a GroupReplies card uses its own subject, and the card containers carry none, so a link in another Bot's reply opens that Bot's tab instead of the room's fallback.
  • Cleanliness: no secrets, no debug leftovers, no unrelated files; the checkpoint block in the body is present with pr 2322, claim Dev-2, issue 2319, a checks.at naming this tip and a next. The Owner: Dev-2 / Reviewer: Birfy lines are on a fork head, which the guard exempts.

Not run by me: the full suites and the smoke bench — tests-ok at this tip already covers them, and my scope was one target file plus the revert. No live DSH Desktop window either; the image/lightbox path is pinned by the harness that models replacement semantics.

@github-actions github-actions Bot added the pass label Nov 4, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 4, 2026
Merged via the queue into ThinkFlowLab:main with commit e8edff6 Nov 4, 2026
18 checks passed
@Birfy
Birfy deleted the dev/2-bubble-image-delegate branch November 4, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client: a chat bubble's image renders as alt text because #2313's link delegate shadows the host's file resolver

2 participants